<?php declare(strict_types=1);

namespace Shopware\Tests\Unit\Administration\Controller;

use Doctrine\DBAL\Connection;
use League\Flysystem\UnableToReadFile;
use League\OAuth2\Server\Exception\OAuthServerException;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Shopware\Administration\Controller\AdministrationController;
use Shopware\Administration\Events\PreResetExcludedSearchTermEvent;
use Shopware\Administration\Framework\Routing\KnownIps\KnownIpsCollector;
use Shopware\Administration\Snippet\SnippetFinderInterface;
use Shopware\Core\Checkout\Customer\CustomerCollection;
use Shopware\Core\Checkout\Customer\CustomerEntity;
use Shopware\Core\Defaults;
use Shopware\Core\Framework\Adapter\Filesystem\PrefixFilesystem;
use Shopware\Core\Framework\Adapter\Twig\TemplateFinder;
use Shopware\Core\Framework\Api\Context\SystemSource;
use Shopware\Core\Framework\Api\OAuth\SymfonyBearerTokenValidator;
use Shopware\Core\Framework\Context;
use Shopware\Core\Framework\DataAbstractionLayer\DefinitionInstanceRegistry;
use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
use Shopware\Core\Framework\DataAbstractionLayer\Search\EntitySearchResult;
use Shopware\Core\Framework\Log\Package;
use Shopware\Core\Framework\Routing\RoutingException;
use Shopware\Core\Framework\Store\Services\FirstRunWizardService;
use Shopware\Core\Framework\Util\HtmlSanitizer;
use Shopware\Core\Framework\Uuid\Uuid;
use Shopware\Core\Framework\Validation\Exception\ConstraintViolationException;
use Shopware\Core\System\Currency\CurrencyCollection;
use Shopware\Core\System\Currency\CurrencyEntity;
use Shopware\Core\System\Language\LanguageCollection;
use Shopware\Core\System\Language\LanguageEntity;
use Shopware\Core\System\Locale\LocaleEntity;
use Shopware\Core\System\SalesChannel\SalesChannelEntity;
use Shopware\Core\Test\Stub\DataAbstractionLayer\StaticEntityRepository;
use Shopware\Core\Test\Stub\Framework\DataAbstractionLayer\TestEntityDefinition;
use Shopware\Core\Test\Stub\Framework\IdsCollection;
use Shopware\Core\Test\Stub\SystemConfigService\StaticSystemConfigService;
use Symfony\Component\DependencyInjection\Container;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
use Twig\Environment;

/**
 * @internal
 */
#[Package('checkout')]
#[CoversClass(AdministrationController::class)]
class AdministrationControllerTest extends TestCase
{
    private MockObject&Connection $connection;

    private Context $context;

    /** @var MockObject&EntityRepository<CurrencyCollection> */
    private MockObject&EntityRepository $currencyRepository;

    private MockObject&DefinitionInstanceRegistry $definitionRegistry;

    private MockObject&EventDispatcherInterface $eventDispatcher;

    private MockObject&PrefixFilesystem $fileSystemOperator;

    private MockObject&HtmlSanitizer $htmlSanitizer;

    private MockObject&ParameterBagInterface $parameterBag;

    private string $shopwareCoreDir;

    private string $serviceRegistryUrl;

    /**
     * @var MockObject&EntityRepository<LanguageCollection>
     */
    private MockObject&EntityRepository $languageRepository;

    private string $refreshTokenTtl;

    private IdsCollection $ids;

    protected function setUp(): void
    {
        $this->connection = $this->createMock(Connection::class);
        $this->context = Context::createDefaultContext();
        $this->definitionRegistry = $this->createMock(DefinitionInstanceRegistry::class);
        $this->currencyRepository = $this->createMock(EntityRepository::class);
        $this->eventDispatcher = $this->createMock(EventDispatcherInterface::class);
        $this->fileSystemOperator = $this->createMock(PrefixFilesystem::class);
        $this->htmlSanitizer = $this->createMock(HtmlSanitizer::class);
        $this->parameterBag = $this->createMock(ParameterBagInterface::class);
        $this->shopwareCoreDir = __DIR__ . '/../../../../src/Core/';
        $this->serviceRegistryUrl = 'https://registry.services.shopware.io';
        $this->languageRepository = $this->createMock(EntityRepository::class);
        $this->refreshTokenTtl = 'P1W';

        $this->ids = new IdsCollection();
    }

    public function testIndexPerformsOnSearchOfCurrency(): void
    {
        $this->parameterBag->expects($this->any())->method('has')->willReturn(true);
        $this->parameterBag->expects($this->any())->method('get')->willReturn(true);

        $controller = $this->createAdministrationController();

        $container = new Container();
        $twig = $this->createMock(Environment::class);

        $twig->expects($this->once())->method('render')
            ->willReturnArgument(0)
            ->with(
                '',
                [
                    'features' => [],
                    'systemLanguageId' => Defaults::LANGUAGE_SYSTEM,
                    'defaultLanguageIds' => [Defaults::LANGUAGE_SYSTEM],
                    'systemCurrencyId' => Defaults::CURRENCY,
                    'systemCurrencyISOCode' => 'fakeIsoCode',
                    'liveVersionId' => Defaults::LIVE_VERSION,
                    'firstRunWizard' => false,
                    'apiVersion' => null,
                    'cspNonce' => null,
                    'adminEsEnable' => true,
                    'storefrontEsEnable' => true,
                    'serviceRegistryUrl' => $this->serviceRegistryUrl,
                    'refreshTokenTtl' => 7 * 86400 * 1000,
                    'productStreamIndexingEnabled' => true,
                ]
            );

        $container->set('twig', $twig);
        $controller->setContainer($container);

        $currencyCollection = new CurrencyCollection();
        $currency = new CurrencyEntity();
        $currency->setId(Uuid::randomHex());
        $currency->setIsoCode('fakeIsoCode');
        $currencyCollection->add($currency);

        $this->currencyRepository->expects($this->once())->method('search')->willReturn(
            new EntitySearchResult(
                'currency',
                1,
                $currencyCollection,
                null,
                new Criteria(),
                $this->context
            )
        );

        $response = $controller->index(new Request(), $this->context);

        static::assertNotFalse($response->getContent());
        static::assertSame(Response::HTTP_OK, $response->getStatusCode());
    }

    public function testCheckCustomerEmailValidWithoutException(): void
    {
        $controller = $this->createAdministrationController();
        $request = new Request([], ['email' => 'random@email.com']);

        $response = $controller->checkCustomerEmailValid($request, $this->context);
        static::assertNotFalse($response->getContent());
        static::assertSame(
            json_encode(['isValid' => true]),
            $response->getContent()
        );
    }

    public function testCheckCustomerEmailValidWithBoundSalesChannelIdValid(): void
    {
        $controller = $this->createAdministrationController(new CustomerCollection(), true);
        $request = new Request([], ['email' => 'random@email.com', 'boundSalesChannelId' => Uuid::randomHex()]);

        $response = $controller->checkCustomerEmailValid($request, $this->context);
        static::assertNotFalse($response->getContent());
        static::assertSame(
            json_encode(['isValid' => true]),
            $response->getContent()
        );
    }

    public function testCheckCustomerEmailValidThrowErrorWithNullEmailParameter(): void
    {
        $this->expectException(RoutingException::class);

        $controller = $this->createAdministrationController();
        $request = new Request();

        $controller->checkCustomerEmailValid($request, $this->context);
    }

    public function testCheckCustomerEmailValidWithConstraintException(): void
    {
        static::expectException(ConstraintViolationException::class);

        $customer = $this->buildCustomerEntity();

        $controller = $this->createAdministrationController(new CustomerCollection([$customer]));
        $request = new Request([], ['email' => 'random@email.com']);

        $controller->checkCustomerEmailValid($request, $this->context);
    }

    public function testCheckCustomerEmailValidWithBoundSalesChannelIdInvalid(): void
    {
        $this->expectException(RoutingException::class);

        $controller = $this->createAdministrationController(new CustomerCollection(), true);
        $request = new Request([], ['email' => 'random@email.com', 'boundSalesChannelId' => true]);

        $controller->checkCustomerEmailValid($request, $this->context);
    }

    public function testCheckCustomerEmailValidWithBoundSalesChannelWithCustomerExistsInSalesChannel(): void
    {
        static::expectException(ConstraintViolationException::class);

        $customer = $this->buildCustomerEntity();
        $salesChannel = new SalesChannelEntity();
        $salesChannel->setId(Uuid::randomHex());
        $salesChannel->setName('New Sales Channel');

        $customer->setBoundSalesChannel($salesChannel);

        $controller = $this->createAdministrationController(new CustomerCollection([$customer]), true);
        $request = new Request([], ['email' => 'random@email.com', 'boundSalesChannelId' => $salesChannel->getId()]);

        $controller->checkCustomerEmailValid($request, $this->context);
    }

    public function testCheckCustomerEmailValidWithBoundSalesChannelWithCustomerExistsInAllSalesChannel(): void
    {
        static::expectException(ConstraintViolationException::class);

        $customer = $this->buildCustomerEntity();

        $controller = $this->createAdministrationController(new CustomerCollection([$customer]), true);
        $request = new Request([], ['email' => 'random@email.com', 'boundSalesChannelId' => Uuid::randomHex()]);

        $controller->checkCustomerEmailValid($request, $this->context);
    }

    public function testKnownIpsReturnsIpsFromRequest(): void
    {
        $controller = $this->createAdministrationController();
        $response = $controller->knownIps(new Request(server: ['REMOTE_ADDR' => '127.0.0.1']));

        static::assertInstanceOf(JsonResponse::class, $response);
        static::assertNotFalse($response->getContent());
        static::assertJsonStringEqualsJsonString(
            '{"ips":[{"name":"global.sw-multi-tag-ip-select.knownIps.you","value":"127.0.0.1"}]}',
            $response->getContent()
        );
    }

    public function testPluginIndexReturnsNotFoundResponse(): void
    {
        $controller = $this->createAdministrationController();

        $this->fileSystemOperator->expects($this->once())
            ->method('read')
            ->with('bundles/foo/meteor-app/index.html')
            ->willThrowException(new UnableToReadFile());
        $response = $controller->pluginIndex('foo');

        static::assertSame(Response::HTTP_NOT_FOUND, $response->getStatusCode());
        static::assertSame('Plugin index.html not found', $response->getContent());
    }

    public function testPluginIndexReturnsUnchangedFileIfNoReplaceableStringIsFound(): void
    {
        $controller = $this->createAdministrationController();

        $fileContent = '<html><head></head><body></body></html>';
        $this->fileSystemOperator->expects($this->once())
            ->method('read')
            ->with('bundles/foo/meteor-app/index.html')
            ->willReturn($fileContent);
        $response = $controller->pluginIndex('foo');

        static::assertSame(Response::HTTP_OK, $response->getStatusCode());
        static::assertSame($fileContent, $response->getContent());
    }

    public function testPluginIndexReplacesAsset(): void
    {
        $controller = $this->createAdministrationController();

        $fileContent = '<html><head><base href="__$ASSET_BASE_PATH$__" /></head><body></body></html>';
        $this->fileSystemOperator->expects($this->once())
            ->method('read')
            ->with('bundles/foo/meteor-app/index.html')
            ->willReturn($fileContent);

        $this->fileSystemOperator->expects($this->once())
            ->method('publicUrl')
            ->with('/')
            ->willReturn('http://localhost/bundles/');

        $response = $controller->pluginIndex('foo');

        static::assertSame(Response::HTTP_OK, $response->getStatusCode());

        $content = $response->getContent();
        static::assertIsString($content);
        static::assertStringNotContainsString('__$ASSET_BASE_PATH$__', $content);
        static::assertStringContainsString('http://localhost/bundles/', $content);
    }

    public function testResetExcludedSearchTermThrowsRoutingException(): void
    {
        $this->expectExceptionObject(RoutingException::languageNotFound($this->context->getLanguageId()));

        $this->connection->expects($this->once())->method('fetchOne')->willReturn(false);
        $controller = $this->createAdministrationController();

        $controller->resetExcludedSearchTerm($this->context);
    }

    #[DataProvider('excludedTerms')]
    public function testResetExcludedSearchTerm(
        ?string $sourceLanguage,
        string|false $deLanguageId,
        string|false $enLanguageId,
        Context $context
    ): void {
        $excludedTerms = $this->getExcludedTerms($sourceLanguage);
        $searchConfigId = Uuid::randomHex();

        $this->connection->expects($this->any())->method('fetchOne')
            ->willReturnOnConsecutiveCalls($searchConfigId, $deLanguageId, $enLanguageId);

        if ($sourceLanguage === null) {
            $this->eventDispatcher->expects($this->once())->method('dispatch')
                ->willReturn(new PreResetExcludedSearchTermEvent($searchConfigId, $excludedTerms, $context));
        } else {
            $this->eventDispatcher->expects($this->never())->method('dispatch');
        }

        $this->connection->expects($this->once())->method('executeStatement')
            ->with(
                'UPDATE `product_search_config` SET `excluded_terms` = :excludedTerms WHERE `id` = :id',
                [
                    'excludedTerms' => json_encode($excludedTerms, \JSON_THROW_ON_ERROR),
                    'id' => $searchConfigId,
                ]
            );

        $controller = $this->createAdministrationController();

        $response = $controller->resetExcludedSearchTerm($context);

        static::assertNotFalse($response->getContent());
        static::assertJsonStringEqualsJsonString('{"success":true}', $response->getContent());
    }

    public function testSanitizeHtmlThrowsRoutingExceptionWhenMissingParameter(): void
    {
        $this->expectExceptionObject(RoutingException::missingRequestParameter('html'));

        $controller = $this->createAdministrationController();

        $controller->sanitizeHtml(new Request(), $this->context);
    }

    public function testSanitizeHtmlInvokesSanitizerWhenFieldIsEmpty(): void
    {
        $this->htmlSanitizer->expects($this->once())->method('sanitize')->willReturn('');

        $controller = $this->createAdministrationController();
        $response = $controller->sanitizeHtml(new Request([], ['html' => '<br/>', 'field' => '']), $this->context);

        static::assertSame(Response::HTTP_OK, $response->getStatusCode());
        static::assertNotFalse($response->getContent());
        static::assertJsonStringEqualsJsonString('{"preview":""}', $response->getContent());
    }

    public function testSanitizeHtmlThrowsRoutingExceptionWhenPropertyIsNotFound(): void
    {
        $field = 'test_entity.unknownProperty';
        $this->expectExceptionObject(RoutingException::invalidRequestParameter($field));

        $entityDefinition = new TestEntityDefinition();
        $entityDefinition->compile($this->definitionRegistry);
        $this->definitionRegistry->expects($this->once())->method('getByEntityName')->willReturn($entityDefinition);

        $controller = $this->createAdministrationController();
        $controller->sanitizeHtml(new Request([], ['html' => '<br/>', 'field' => $field]), $this->context);
    }

    public function testSanitizeHtmlStripsTagsWhenPropertyHTMLIsIsNotAllowed(): void
    {
        $entityDefinition = new TestEntityDefinition();
        $entityDefinition->compile($this->definitionRegistry);
        $this->definitionRegistry->expects($this->once())->method('getByEntityName')->willReturn($entityDefinition);

        $controller = $this->createAdministrationController();
        $response = $controller->sanitizeHtml(new Request([], ['html' => '<p>test</p>', 'field' => 'test_entity.id']), $this->context);

        static::assertSame(Response::HTTP_OK, $response->getStatusCode());
        static::assertNotFalse($response->getContent());
        static::assertJsonStringEqualsJsonString('{"preview":"test"}', $response->getContent());
    }

    public function testSanitizeHtmlReturnsRawHTMLWhenHTMLIsAllowedAndFlagIsNotSanitized(): void
    {
        $html = '<p>test</p>';
        $entityDefinition = new TestEntityDefinition();
        $entityDefinition->compile($this->definitionRegistry);
        $this->definitionRegistry->expects($this->once())->method('getByEntityName')->willReturn($entityDefinition);

        $controller = $this->createAdministrationController();
        $response = $controller->sanitizeHtml(new Request([], ['html' => $html, 'field' => 'test_entity.idAllowHtml']), $this->context);

        static::assertSame(Response::HTTP_OK, $response->getStatusCode());
        static::assertNotFalse($response->getContent());
        static::assertJsonStringEqualsJsonString('{"preview":"' . $html . '"}', $response->getContent());
    }

    public function testSanitizeHtmlInvokesSanitizerWhenHTMLIsAllowedAndFlagIsSanitized(): void
    {
        $sanitized = 'test';
        $entityDefinition = new TestEntityDefinition();
        $entityDefinition->compile($this->definitionRegistry);
        $this->definitionRegistry->expects($this->once())->method('getByEntityName')->willReturn($entityDefinition);

        $this->htmlSanitizer->expects($this->once())->method('sanitize')->willReturn($sanitized);

        $controller = $this->createAdministrationController();
        $response = $controller->sanitizeHtml(
            new Request([], ['html' => '<p>test</p>', 'field' => 'test_entity.idAllowHtmlSanitized']),
            $this->context
        );

        static::assertSame(Response::HTTP_OK, $response->getStatusCode());
        static::assertNotFalse($response->getContent());
        static::assertJsonStringEqualsJsonString('{"preview":"' . $sanitized . '"}', $response->getContent());
    }

    public function testSnippetFinderAddsEnglishSnippetWhenLocaleIsDifferent(): void
    {
        $controller = $this->createAdministrationController();

        $response = $controller->snippets(new Request(query: ['locale' => 'de-DE']));

        static::assertNotFalse($response->getContent());
        static::assertJsonStringEqualsJsonString('{"de-DE":[],"en-GB":[]}', $response->getContent());
    }

    public function testGetUnauthenticatedSnippetsWithoutAuthentication(): void
    {
        $controller = $this->createUnauthenticatedAdministrationController();

        $request = new Request(query: ['locale' => 'en-GB']);
        $response = $controller->snippets($request);
        $snippets = \json_decode($response->getContent() ?: '', true, 512, \JSON_THROW_ON_ERROR)['en-GB'];

        static::assertCount(2, $snippets);
        static::assertArrayHasKey('global', $snippets);
        static::assertArrayHasKey('sw-login', $snippets);
    }

    public function testGetAllActivatedLanguagesLocales(): void
    {
        $expectedLocales = [
            $this->ids->create('de-DE') => 'de-DE',
            $this->ids->create('en-GB') => 'en-GB',
            $this->ids->create('jp-JP') => 'jp-JP',
        ];

        $languages = \array_map(static function (string $locale, string $languageId) {
            $localeEntity = new LocaleEntity();
            $localeEntity->setCode($locale);

            $languageEntity = new LanguageEntity();
            $languageEntity->setId($languageId);
            $languageEntity->setLocale($localeEntity);

            return $languageEntity;
        }, $expectedLocales, \array_keys($expectedLocales));

        $context = Context::createDefaultContext();
        $languageRepository = $this->createMock(EntityRepository::class);
        $languageRepository->method('search')
            ->willReturn(new EntitySearchResult(
                'language',
                2,
                new LanguageCollection($languages),
                null,
                new Criteria(),
                $context,
            ));
        $controller = $this->createAdministrationController(null, false, $languageRepository);

        $jsonResponse = $controller->getLocales(new Request(), $context);
        static::assertInstanceOf(JsonResponse::class, $jsonResponse);
        static::assertSame(Response::HTTP_OK, $jsonResponse->getStatusCode());

        $content = $jsonResponse->getContent();
        static::assertNotFalse($content);

        $actualLocales = \json_decode($content, true);
        static::assertEquals($expectedLocales, $actualLocales);
    }

    public static function excludedTerms(): \Generator
    {
        $languageId = Uuid::fromStringToHex('languageId');

        yield 'default excluded terms' => [
            null,
            false,
            false,
            new Context(new SystemSource(), [], Defaults::CURRENCY),
        ];

        yield 'english excluded terms' => [
            'en',
            false,
            Uuid::fromHexToBytes($languageId),
            new Context(new SystemSource(), [], Defaults::CURRENCY, [$languageId]),
        ];

        yield 'german excluded terms' => [
            'de',
            Uuid::fromHexToBytes($languageId),
            false,
            new Context(new SystemSource(), [], Defaults::CURRENCY, [$languageId]),
        ];
    }

    /**
     * @param ?EntityRepository<LanguageCollection> $languageRepository
     */
    protected function createAdministrationController(
        ?CustomerCollection $collection = null,
        bool $isCustomerBoundToSalesChannel = false,
        ?EntityRepository $languageRepository = null,
        (SnippetFinderInterface&MockObject)|null $snippetFinder = null,
        (SymfonyBearerTokenValidator&MockObject)|null $tokenValidator = null,
    ): AdministrationController {
        $collection = $collection ?? new CustomerCollection();

        /** @var StaticEntityRepository<CustomerCollection> $customerRepository */
        $customerRepository = new StaticEntityRepository([$collection]);

        return new AdministrationController(
            $this->createMock(TemplateFinder::class),
            $this->createMock(FirstRunWizardService::class),
            $snippetFinder ?? $this->createMock(SnippetFinderInterface::class),
            [],
            new KnownIpsCollector(),
            $this->connection,
            $this->eventDispatcher,
            $this->shopwareCoreDir,
            $customerRepository,
            $this->currencyRepository,
            $this->htmlSanitizer,
            $this->definitionRegistry,
            $this->parameterBag,
            new StaticSystemConfigService([
                'core.systemWideLoginRegistration.isCustomerBoundToSalesChannel' => $isCustomerBoundToSalesChannel,
            ]),
            $this->fileSystemOperator,
            $this->serviceRegistryUrl,
            $languageRepository ?? $this->languageRepository,
            $tokenValidator ?? $this->createMock(SymfonyBearerTokenValidator::class),
            $this->refreshTokenTtl,
        );
    }

    private function createUnauthenticatedAdministrationController(): AdministrationController
    {
        /** @var SnippetFinderInterface&MockObject $snippetFinder */
        $snippetFinder = $this->createMock(SnippetFinderInterface::class);
        $snippetFinder
            ->expects($this->once())
            ->method('findSnippets')
            ->willReturn([
                'global' => [],
                'sw-login' => [],
                'entityCategories' => [],
                'help-center' => [],
                'locale' => [],
                'mt-text-editor-toolbar-button-link' => [],
                'sales-channel-theme' => [],
                'sidebar' => [],
                'sw-ai-copilot-warning' => [],
                'sw-app' => [],
                'sw-base-filter' => [],
                'sw-boolean-filter' => [],
                'sw-bulk-edit' => [],
                'sw-category' => [],
                'sw-category-tree-field' => [],
                'sw-cms' => [],
                'sw-config-form-renderer' => [],
            ]);

        $tokenValidator = $this->createMock(SymfonyBearerTokenValidator::class);
        $tokenValidator
            ->expects($this->once())
            ->method('validateAuthorization')
            ->willThrowException(new OAuthServerException('', 0, ''));

        return $this->createAdministrationController(
            null,
            false,
            null,
            $snippetFinder,
            $tokenValidator,
        );
    }

    private function buildCustomerEntity(): CustomerEntity
    {
        $customer = new CustomerEntity();
        $customer->setId(Uuid::randomHex());

        return $customer;
    }

    /**
     * @return string[]
     */
    private function getExcludedTerms(?string $language): array
    {
        if (!\in_array($language, ['de', 'en'], true)) {
            return [];
        }

        return require $this->shopwareCoreDir . '/Migration/Fixtures/stopwords/' . $language . '.php';
    }
}
